背景
小程序和低代码平台需要执行动态下发的代码。当时调研到的部分 VM 未完整通过 ES5 或 ES2015 测试用例,不能满足生产环境的兼容性要求,因此实现了自己的 JSVM 引擎。
目前已放到 github 上 :jsvm 2 传送门。 目前除了 WithStatement 语句外,其他 es5 特性均支持,单测覆盖 91%
原理
基于语法树的 JSVM 需要遍历 AST,并为 Babel 的 Program、VariableDeclaration 等节点实现对应的执行逻辑。下面是一个简化的 VM:
const babelParser = require("@babel/parser");
const babelTraverse = require("@babel/traverse").default;
// 执行引擎
class ExecutionEngine {
constructor() {
this.globalScope = {}; // 全局作用域
}
// 执行语法树
execute(ast) {
babelTraverse(ast, {
enter: (path) => {
const node = path.node;
switch (node.type) {
case 'Program':
this.executeProgram(node);
break;
case 'VariableDeclaration':
this.executeVariableDeclaration(node);
break;
case 'Identifier':
return this.executeIdentifier(node);
case 'NumericLiteral':
return this.executeNumericLiteral(node);
case 'BinaryExpression':
return this.executeBinaryExpression(node);
// 处理其他类型的语法树节点
// ...
}
}
});
}
// 执行 Program 节点
executeProgram(node) {
for (const statement of node.body) {
this.execute(statement);
}
}
// 执行 VariableDeclaration 节点
executeVariableDeclaration(node) {
const variableName = node.declarations[0].id.name;
const variableValue = this.execute(node.declarations[0].init);
this.globalScope[variableName] = variableValue;
}
// 执行 Identifier 节点
executeIdentifier(node) {
const variableName = node.name;
return this.globalScope[variableName];
}
// 执行 NumericLiteral 节点
executeNumericLiteral(node) {
return node.value;
}
// 执行 BinaryExpression 节点
executeBinaryExpression(node) {
const leftValue = this.execute(node.left);
const operator = node.operator;
const rightValue = this.execute(node.right);
// 执行二元运算
switch (operator) {
case '+':
return leftValue + rightValue;
case '-':
return leftValue - rightValue;
case '*':
return leftValue * rightValue;
case '/':
return leftValue / rightValue;
// 处理其他运算符
// ...
}
}
}
// 输入 JavaScript 代码
const code = `
let x = 5;
x + 10;
`;
// 使用 Babel 解析代码为 AST
const ast = babelParser.parse(code, {
sourceType: "module",
});
// 创建执行引擎并执行语法树
const engine = new ExecutionEngine();
engine.execute(ast);
console.log(engine.globalScope['x']); // 输出:5
完整实现步骤
- 词法分析(Lexical Analysis)和语法分析(Syntax Analysis):将输入的 JavaScript 代码转换为抽象语法树(Abstract Syntax Tree,AST)。这里直接使用 Babel 来完成
- 作用域分析(Scope Analysis):在语法树中进行作用域分析,确定变量的定义和引用所在的作用域。这可以通过构建作用域链(scope chain)来实现,每个作用域都有一个指向父级作用域的引用。参考 scope.ts
- 变量和函数声明(Variable and Function Declaration):在作用域分析的基础上,将变量和函数声明添加到适当的作用域中。这可以在作用域中创建变量和函数的绑定,并为后续的执行做准备。参考 declare
- 执行引擎(Execution Engine):遍历语法树,并执行相应的操作。执行引擎根据语法树节点的类型执行不同的操作,例如变量赋值、函数调用、条件判断等。引擎使用作用域链来解析变量和函数的引用,并根据运行时上下文执行相应的操作。参考 visitor 以及 standard
- 作用域和上下文管理:在执行过程中,需要管理作用域和运行时上下文。每次进入一个函数时,会创建一个新的函数执行上下文,包括函数的作用域、参数和局部变量。在函数执行完成后,上下文会被销毁。参考 Function
- 值的计算和存储:执行引擎根据需要计算表达式的值,并将结果存储在适当的位置,例如变量、对象属性或函数返回值。参考 statement. ts,object
- 控制流管理:执行引擎处理控制流语句,例如条件语句、循环语句和异常处理。根据条件的结果,执行引擎决定执行的路径,并更新程序计数器以指向下一条要执行的语句。参考 conditional
- 内建函数和对象:实现一些内建的 JavaScript 函数和对象,例如 console、Array、Object 等。这些函数和对象可以通过在执行引擎中添加相应的逻辑来实现。参考 context
效果
react-case 展示了 JSVM 2 执行 React 的示例。该引擎也已在实际业务中持续运行;本文不据此推导其在所有场景下的可靠性。
性能
主要开销来自反复创建执行上下文。在包含大量 for 循环的场景中,性能比未开启 JIT 的 V8 低 2 个数量级,因此不适合大量数据计算。移动端低端机的分类页录屏测试覆盖了复杂交互场景,录屏中未观察到明显差异;这一结果只适用于该测试条件。
压缩&混淆
Babel 生成的 AST 体积较大,普通的 console.log 语句也会产生较多节点。上线前对 AST 做了等价压缩;压缩结果仍大于原始代码,但可用于当前包体约束。为避免端上解压缩的开销,生产环境的 JSVM 改为直接执行压缩后的 AST 协议。这部分实现未开源,执行原理未变。

线上问题排查
动态下发代码报错后难以定位源码位置。为此,基于压缩后的 AST 定义了一套 sourcemap 协议,并提供后台调试工具:输入报错 ID 后可反解析到对应的行列位置。

